Java-Entwicklern stand bislang eine Vielzahl von Werkzeugen zur Verfügung, mit denen sie festlegen konnten, in welcher Art und Weise von Klassen vererbt werden kann. Ist eine Klasse package-private, kann sie nur im selben Paket referenziert werden, sämtliche Unterklassen müssen sich also in diesem befinden. Damit entfällt aber die Möglichkeit, die Oberklasse selbst außerhalb dieses Pakets zu nutzen. Mit dem final-Modifier kann das Erben von einer Klasse verboten werden, dies verhindert aber nicht, neue Implementierungen einer gemeinsamen Oberklasse zu entwerfen. Möchte man hingegen eine gemeinsame Oberklasse oder ein Interface etwa für APIs verwendbar machen, jedoch verhindern, dass neue Kindklassen implementiert und verwendet werden, existierte bislang lediglich die Möglichkeit, den Konstruktor der Oberklasse package-private zu setzen und sämtliche Unterklassen im selben Paket zu behalten. Genau hier setzen Sealed Classes an. Durch den neu eingeführten Modifier sealed und die Direktive permits wird bereits am oberen Ende der Vererbungshierarchie festgelegt, welche weiteren Implementierungen einer Klasse existieren dürfen. In Listing 1 definieren wir am Interface Payment, dass es nur durch die Klassen InvoicePayment und UpfrontPayment implementiert werden darf.
Listing 1
public sealed interface Payment permits InvoicePayment, UpfrontPayment { BigDecimal getAmount(); } public final class InvoicePayment implements Payment { private final BigDecimal amount; private final LocalDate toPayUntil; public InvoicePayment(BigDecimal amount, LocalDate toPayUntil) { this.amount = amount; this.toPayUntil = toPayUntil; } public BigDecimal getAmount() { return this.amount; } public LocalDate getToPayUntil() { return this.toPayUntil; } } public non-sealed class UpfrontPayment implements Payment { private final BigDecimal amount; public UpfrontPayment(BigDecimal amount) { this.amount = amount; } public BigDecimal getAmount() { return this.amount; } }
Auf gleiche Art und Weise könnte statt eines Interface auch eine Klasse oder eine abstrakte Klasse verwendet werden. Ansonsten kann mit der Vererbung verfahren werden wie gewohnt – vom Interface kann eine normale Klasse, ein Record (Preview-Feature), eine Enum oder ein weiteres Interface erben. Im Beispiel sind InvoicePayment als final und UpfrontPayment als non-sealed deklariert. Das liegt daran, dass für Klassen, die von einer Sealed Class erben, die Entscheidung getroffen werden muss, wie die weitere Vererbungshierarchie aussehen soll. Sie können entweder final sein und nicht weitervererbt werden, ebenfalls sealed sein und eingeschränkt Vererbung zulassen, oder sie sind non-sealed – von ihnen dürfen dann beliebig weitere Unterklassen gebildet werden. Ein Standardverhalten gibt es nicht, die Entscheidung muss bei Erstellung der Klasse explizit getroffen werden. Ausgenommen sind Records und Enums, die implizit final sind und generell nicht weitervererbt werden können (Listing 2). Die gesamte Syntaxbeschreibung lässt sich in Listing 3 nachvollziehen.
Listing 2
public sealed interface Payment permits InvoicePayment, UpfrontPayment { BigDecimal amount(); } public record InvoicePayment(LocalDate toBePayedUntil, BigDecimal amount) implements Payment { } public record UpfrontPayment(BigDecimal amount) implements Payment { }
Listing 3
NormalClassDeclaration: {ClassModifier} class TypeIdentifier [TypeParameters] [Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody ClassModifier: (one of) Annotation public protected private abstract static sealed final non-sealed strictfp PermittedSubclasses: permits ClassTypeList ClassTypeList: ClassType {, ClassType}
Nutzen in JDK 15
Das bereits vorgestellte Beispiel in Listing 2 soll uns als Grundlage dienen, den Nutzen mit aktivierten Preview-Features im aktuellen JDK zu verstehen. Dabei betrachten wir die kürzere Schreibweise mit Records. Nehmen wir an, wir stellen ein Java-Zahlungs-API bereit. Kunden, die es nutzen möchten, binden einfach eine Java-Bibliothek mit der nötigen Schnittstelle ein, schon können sie sie in ihrer Software benutzen. Die Klasse PaymentProcessor (Listing 4) nimmt für die Methode processPayment eine Instanz von Payment entgegen und verarbeitet sie je nach Typ. Mit Sealed Classes ist nun sichergestellt, dass es sich bei der übergebenen Instanz von Payment nur um ein UpfrontPayment oder ein InvoicePayment handeln kann – eine andere Klasse, die von Payment erbt, die etwa von einem User erzeugt wurde, kann nicht existieren.
Listing 4
public class PaymentProcessor { public void processPayment(Payment payment) { if(payment instanceof UpfrontPayment c) { continueProcessIfPaymentArrives(...); } else if (payment instanceof InvoicePayment i) { continueProcessImmediately(...); } } public Strinbg buildPaymentDescription(Payment payment) { if(payment instanceof UpfrontPayment c) { return String.format("This is an Upfront Payment of %s €", c.amount()); } else if (payment instanceof InvoicePayment i) { return String.format("This is an Invoice Payment of %s €, due on %s", i.amount(), i.toBePayedUntil()); } else { throw new IllegalStateException("Payment Type not expected: " + payment.getClass()); } } }
Der Entwickler des API kann sich in diesem Codebeispiel sicher sein, alle Fälle behandelt zu haben, und muss seinen Code weniger defensiv schreiben. Leider ist der Compiler aber noch nicht so clever wie unser Entwickler, wie man an der Methode buildPaymentDescription sieht. Obwohl in den beiden if-Statements sämtliche Möglichkeiten erschöpft wurden, sind wir gezwungen, einen else-Zweig mit einer Sonderbehandlung einzuführen – die niemals ausgeführt werden wird.
Neben dem offensichtlichen Nutzen des neuen Sprachfeatures gibt es auch kritische Stimmen, die darin eine Verletzung von OOP-Prinzipien sehen. Eine Oberklasse, die sämtliche Klassen, die von ihr erben, kennen muss – wie passt das zu Kapselung und einer objektorientierten Sprache? Hierbei muss die Frage gestellt werden: Warum abstrahieren und kapseln wir in der Softwareentwicklung? Meist, um Flexibilität zu erlangen. Und um uns an unserem Beispiel zu orientieren: An vielen Stellen im Code reicht es, zu wissen, dass wir eine Instanz von Payment erhalten oder in ein API hineingeben. Die genaue Klasse zu kennen, ist nicht notwendig, stattdessen werden die gleichen Eigenschaften aller Implementierungen der Klasse gleichbehandelt. Anstatt für jede Unterklasse eigenen Code zu schreiben, können wir den gleichen Code wiederverwenden. Würde Payment nichts von seinen Implementierungen wissen müssen, könnten wir neue Unterklassen zu unserer Applikation hinzufügen, ohne den bisherigen Code anpassen zu müssen. Theoretisch können wir sogar neue Unterklassen von Payment zur Laufzeit laden und damit eine Klasse verarbeiten, die zur Zeit der Kompilierung noch gar nicht bekannt war. Nutzen wir hingegen Sealed Classes, tun wir das, um unser Domänenmodell genauer zu beschreiben. Wir möchten darauf hinweisen, dass die Anzahl der Unterklassen fest definiert ist und es hier keine Dynamik geben darf. Unser Code kann auf Basis dieser Annahme Businesslogik implementieren. Kommt eine neue Unterklasse zu den existierenden hinzu, wird es einen Kompilierfehler geben. Dieser ist erwünscht, denn ein essenzieller Bestandteil des Domänenmodells hat sich geändert, diesen Umstand möchten wir an allen Stellen, die es betrifft, mitbekommen. Gleichzeitig büßen wir die Fähigkeit zur Abstraktion durch diese neue Kopplung nicht ein.
Ein kurzer Abstecher in algebraische Datentypen
Der Text zum JEP 360: Sealed Classes erwähnt „Algebraic Data Types“. Diese näher zu beleuchten, hilft zu verstehen, warum die Einführung von Sealed Classes einen großen Schritt hin zu funktionaler Programmierung in Java darstellt. Algebraische Datentypen setzen sich in der Regel aus Produkttypen und Summentypen zusammen und sind ein wichtiger Bestandteil von statisch typisierten funktionalen Sprachen wie etwa Haskell oder Scala. Produkttypen kennen wir in Java schon zur Genüge. Sie bezeichnen Typen, die sich aus anderen Typen zusammensetzen, also beispielsweise Records oder Klassen. Der Typ Würfel beispielsweise setzt sich aus den Typen Höhe, Breite und Tiefe zusammen. Produkttypen beschreiben also ein „Und“, sozusagen eine Komposition aus anderen Typen. Summentypen hingegen beschreiben ein „Oder“. Der bekannteste Summentyp in Java ist Boolean, der entweder true oder false ist. Während sich mit Produkttypen die Daten unseres Domänenmodells modellieren lassen, eignen sich Summentypen dazu, Verhalten zu beschreiben. Das Domänenmodell für das oben erwähnte Payment-Beispiel zeichnet sich durch seine Robustheit aus: Wenn wir davon ausgehen, dass es einen Produkttyp Checkout gibt, der sämtliche Kundendaten wie Adresse, Kontodaten und die gekaufte Ware beinhaltet, ist das Payment ein Teil von Checkout. Je nachdem, welche Art von Payment der Kunde auswählt, benötigen wir andere Daten im Konstruktor; bei einem Rechnungskauf geben wir ein Zahlungsziel vor. Die Klasse PaymentProcessor weiß für jede Zahlungsmethode, wie mit ihr zu verfahren ist, und kann durch Pattern Matching leicht auf die typspezifischen Attribute zugreifen. Erweitern wir die Applikation um zusätzliche Zahlungsmethoden, geschieht das nun ausschließlich durch Komposition: Beispielsweise könnten wir eine neue Klasse OnlinePayment einführen, die zusätzlich im Konstruktor noch die E-Mail-Adresse benötigt, mit der der Kunde beim Zahlungsdienstleister registriert ist. Das Attribut payment im Typ Checkout könnte dann drei statt zwei Zustände annehmen.
Ausblick auf die weitere Roadmap
Die bisher aufgeführten Anwendungsfälle sind ohne Frage hilfreich, der größte Nutzen von Sealed Classes liegt jedoch in der Zukunft und wird sich dann offenbaren, wenn sie von Pattern Matching unterstützt werden, beispielsweise in einem Switch Statement. Der Compiler kann dann, ähnlich wie momentan schon für Enumerations, überprüfen, ob sämtliche möglichen Fälle erschöpfend behandelt wurden. Ist das nicht der Fall, würde er eine Default Clause einfordern. Das ist möglich, da durch das Versiegeln der Klasse auch verhindert wird, dass eine neue Implementierung der Oberklasse dynamisch zur Laufzeit geladen wird. Zusätzlich wird der Compiler den Entwickler mit einem Fehler darauf hinweisen, wenn eine neue Implementierung zu den existierenden hinzugefügt, jedoch im Pattern Matching nicht berücksichtigt wird. Das sorgt für robusteren Code und weniger Boilerplate-Code (Listing 5).
Listing 5
// Noch kein gültiger Code! public String processPayment(Payment payment) { return switch(payment) { case UpfrontPayment u -> String.format("This is an Upfront Payment of %s €", c.amount()); case InvoicePayment i -> String.format("This is an Invoice Payment of %s €, due on %s", i.amount(), i.toBePayedUntil()); } }
Fazit
Natürlich sind die meisten der in diesem Artikel vorgestellten Konzepte auch mit den vorigen Java-Versionen umsetzbar. Dass Komposition ein Werkzeug ist, um komplexe Sachverhalte robust abzubilden, ist schon lange kein Geheimnis mehr. Die Effekte der im Vorfeld beschriebenen algebraischen Datentypen sollten vielen Java-Entwicklern nicht neu vorkommen. Sealed Classes stellen aber einen großen Schritt in Richtung einer Zukunft dar, in der der Compiler uns beim Komponieren solcher Domänenmodelle unterstützt – zum einen mit Pattern Matching, das weniger defensiv geschriebenen und eleganteren Code ermöglicht, zum anderen aber auch mit der Unterstützung durch den Compiler, wenn eingeführte Klassen unsere bestehende Geschäftslogik zerstören würden. Gemeinsam mit Records und Pattern Matching stellen Sealed Classes also sicher, dass Konzepte aus der funktionalen Programmierung sich in Java immer idiomatischer umsetzen lassen.
Spring Ecosystem auf der JAX & W-JAX:
● Workshop: Coole neue Java-Features – besserer Code mit Java 9 bis 15
● Neues in Java